﻿namespace PowerSong.SongDatabase
    {
    using System;
    using System.Collections.Generic;
    using System.IO;
    using PowerSong.SongDatabase.ObjectModel;

    /// <summary>
    /// Represents a PowerSong database. This class is the main mechanism by which you can add, retrieve, edit and delete
    /// the various kinds of items that are stored in a PowerSong database.
    /// </summary>
    public class Database
        {

        /// <summary>
        /// This constant is used as a sort of mini-hack to detect that the version of a PowerSong database indicates
        /// that it is actually a legacy export file generated by PowerSong 1.0.
        /// </summary>
        public const int LegacyExportFileVersion = 559903;

        /// <summary>
        /// Indicates the current version number of the database, in a date-like format. Higher numbers represent
        /// later versions of the PowerSong database.
        /// </summary>
        public const int Version = 20110602;

        /// <summary>
        /// The file name of this PowerSong database.
        /// </summary>
        public string FileName { get; private set; }

        internal Dictionary<Guid, BaseEntity> Entities { get; private set; }

        internal List<LogEntry> Activity { get; private set; }

        public Songs Songs { get; private set; }

        public Styles Styles { get; private set; }

        public Licenses Licenses { get; private set; }

        public Categories Categories { get; private set; }

        public Settings Settings { get; private set; }

        public ActivityLog ActivityLog { get; private set; }

        /// <summary>
        /// Private constructor prevents creation of an instance of this class. There are static methods defined in this
        /// class for creating and loading PowerSong databases.
        /// </summary>
        /// <param name="fileName">The file name of the PowerSong database to load.</param>
        private Database()
            {

            // Create empty list of entities
            this.Entities = new Dictionary<Guid, BaseEntity>();
            this.Activity = new List<LogEntry>();

            // Set up properties
            this.Songs = new Songs(this);
            this.Styles = new Styles(this);
            this.Licenses = new Licenses(this);
            this.Categories = new Categories(this);
            this.ActivityLog = new ActivityLog(this);
            this.Settings = new Settings(this, new Configuration());

            }

        /// <summary>
        /// Private constructor prevents creation of an instance of this class. There are static methods defined in this
        /// class for creating and loading PowerSong databases.
        /// </summary>
        /// <param name="stream">The stream to load from.</param>
        private Database(Stream stream)
            : this()
            {
            using (var reader = new BinaryReader(stream))
                {
                ReadDatabaseFromStream(reader);
                }
            }

        /// <summary>
        /// Private constructor prevents creation of an instance of this class. There are static methods defined in this
        /// class for creating and loading PowerSong databases.
        private Database(string fileName)
            : this()
            {

            // Set file name
            this.FileName = fileName;

            // Load all entities on the drive
            using (var reader = new BinaryReader(new FileStream(fileName, FileMode.Open, FileAccess.ReadWrite)))
                {
                ReadDatabaseFromStream(reader);
                }

            }

        private void ReadDatabaseFromStream(BinaryReader reader)
            {

            // Get the version and check it is the same as the current version
            var versionInFile = reader.ReadInt32();
            if (versionInFile == LegacyExportFileVersion) throw new DatabaseIsLegacyExportFileException();
            if (versionInFile != Version) throw new IncompatibleDatabaseException();

            // Keep going until we reach the end
            while (reader.BaseStream.Position < reader.BaseStream.Length)
                {

                // What is the type of the next entity?
                var entityCount = (TapeAction)reader.ReadInt32();
                if (entityCount > 0)
                    {

                    // This is a new or edited entity
                    var entities = Read(reader, (int)entityCount);
                    foreach (var entity in entities)
                        {
                        this.Entities[entity.ID] = entity;
                        }

                    }
                else if (entityCount == TapeAction.DeleteEntity)
                    {

                    // An entity has been recorded as having been deleted
                    var identifier = new Guid(reader.ReadBytes(16));
                    this.Entities.Remove(identifier);

                    }
                else if (entityCount == TapeAction.ActivityAudit)
                    {

                    // This is an activity audit item
                    var data = reader.ReadBytes(26);
                    var entry = LogEntry.Create(data);
                    this.Activity.Add(entry);

                    }
                else
                    {
                    throw new NotImplementedException("Don't know how to handle entity identifier '" + entityCount + "'.");
                    }

                }

            // Is there a configuration block?
            var configurationEntity = First<Configuration>();
            var configuration = configurationEntity == null ? new Configuration() : configurationEntity;
            this.Settings = new Settings(this, configuration);

            }

        /// <summary>
        /// Creates a new in-memory PowerSong database.
        /// Note that no data is persisted. Anything written will exist only in memory unless you extract it.
        /// </summary>
        /// <returns>The database that was just created.</returns>
        public static Database Create()
            {
            return new Database();
            }

        /// <summary>
        /// Creates a new PowerSong database with the given file name.
        /// The database is subsequently loaded and a class instance is returned.
        /// </summary>
        /// <param name="fileName">The file name of the PowerSong database to create.</param>
        /// <returns>The database that was just created.</returns>
        public static Database Create(string fileName)
            {

            // Create the drive file
            File.WriteAllBytes(fileName, BitConverter.GetBytes(Version));

            // Return the drive file
            return new Database(fileName);

            }

        /// <summary>
        /// Loads an existing PowerSong database.
        /// </summary>
        /// <param name="fileName">The file name of the PowerSong database to load.</param>
        /// <returns>The loaded PowerSong database.</returns>
        public static Database Load(string fileName)
            {
            return new Database(fileName);
            }

        /// <summary>
        /// Loads an existing PowerSong database, based on a stream.
        /// Any changes made to the database will not be persisted, because there is no backing file.
        /// </summary>
        /// <param name="stream">The stream containing a PowerSong database, whose position is currently the start of the database data.</param>
        /// <returns>The loaded PowerSong database.</returns>
        public static Database Load(Stream stream)
            {
            return new Database(stream);
            }

        /// <summary>
        /// Loads an existing PowerSong database, based on a series of bytes.
        /// Any changes made to the database will not be persisted, because there is no backing file.
        /// </summary>
        /// <param name="data">The binary data that represents the PowerSong database.</param>
        /// <returns>The loaded PowerSong database.</returns>
        public static Database Load(byte[] data)
            {
            using (var stream = new MemoryStream(data))
                {
                return Load(stream);
                }
            }

        /// <summary>
        /// Compacts the PowerSong database, typically making it smaller and quicker to load.
        /// Every IO action performed on this database is written as an event to the end of the database file so that recovery
        /// in the event of something like a power failure is trivial.
        /// 
        /// It is important to call this method after the end of each session in order to reduce the size of the database
        /// and therefore decrease the amount of time it takes to load it in the future.
        /// 
        /// Nothing happens if you are not actually using a file (ie: you are only storing data in memory).
        /// </summary>
        public void Compact()
            {

            // Clearly, we should only do something if we are actually storing data on disk
            if (!string.IsNullOrEmpty(FileName))
                {

                // Write header
                File.WriteAllBytes(FileName, BitConverter.GetBytes(Version));

                // Write all of the different types of entity
                Write(Filter<License>().ToArray());
                Write(Filter<Category>().ToArray());
                Write(Filter<Style>().ToArray());
                Write(Filter<Song>().ToArray());
                Write(Filter<Configuration>().ToArray());

                // Finish by writing the complete activity log
                using (var writer = new BinaryWriter(new FileStream(FileName, FileMode.Append, FileAccess.Write)))
                    {
                    foreach (var entry in Activity)
                        {
                        writer.Write((int)TapeAction.ActivityAudit);
                        writer.Write(entry.ToByteArray());
                        }
                    }

                }

            }

        /// <summary>
        /// Gets the first entity in the database matching the given type.
        /// </summary>
        /// <typeparam name="T">The type of entity (which must extend the BaseEntity class).</typeparam>
        /// <returns>The first entity matching the given type.</returns>
        public T First<T>() where T : BaseEntity, new()
            {
            foreach (var entity in Entities)
                {
                if (entity.Value is T) return (T)entity.Value;
                }
            return null;
            }

        /// <summary>
        /// Enumerates through all of the entities in the database, and returns a list of all of those entities that
        /// match the given type.
        /// </summary>
        /// <typeparam name="T">The type of entity to filter with (which must extend the BaseEntity class).</typeparam>
        /// <returns>A filtered list of all of the entities which are of the given type.</returns>
        public List<T> Filter<T>() where T : BaseEntity, new()
            {
            var result = new List<T>();
            foreach (var entity in Entities)
                {
                if (entity.Value is T) result.Add((T)entity.Value);
                }
            return result;
            }

        /// <summary>
        /// Gets the single entity stored in this database, which has the given ID.
        /// If you know the type of the entity, use the overloaded generic method instead.
        /// </summary>
        /// <param name="identifier">The ID of the entity to retrieve.</param>
        /// <returns></returns>
        public BaseEntity Get(Guid identifier)
            {
            if (Entities.ContainsKey(identifier)) return Entities[identifier];
            return null;
            }

        /// <summary>
        /// Gets the single entity stored in this database, which has the given ID and is of the given type.
        /// </summary>
        /// <typeparam name="T">The type of entity to find and return.</typeparam>
        /// <param name="identifier">The ID of the entity to retrieve.</param>
        /// <returns>The entity in the database whose ID is the same as the one supplied.</returns>
        public T Get<T>(Guid identifier) where T : BaseEntity, new()
            {
            if (Entities.ContainsKey(identifier))
                {
                var value = Entities[identifier];
                if (value is T) return (T)value;
                }
            return null;
            }

        public void Merge(Database otherDatabase)
            {

            // Process all of the entities in the other database
            foreach (var entity in otherDatabase.Entities)
                {

                // Delete the entity from this database if it already exists
                if (this.Entities.ContainsKey(entity.Key))
                    {
                    Delete(this.Entities[entity.Key]);
                    }

                // Write the entity from the other database into this database
                this.Write(entity.Value);
                }

            // Compact this database
            this.Compact();

            }

        internal BaseEntity[] Read(BinaryReader reader, int entityCount)
            {

            // Create an array to hold the specified number of entities
            var result = new BaseEntity[entityCount];
            for (var index = 0; index < entityCount; index++)
                {

                // Read the details about the file
                var identifier = new Guid(reader.ReadBytes(16));
                var type = reader.ReadInt32();
                var version = reader.ReadInt32();
                var size = reader.ReadInt32();
                var data = reader.ReadBytes(size);

                // Create the entry
                var nextEntity = CreateEntity((FileEntryType)type);
                nextEntity.ID = identifier;

                // Populate the entity with the data
                using (var entityReader = new BinaryReader(new MemoryStream(data)))
                    {
                    var versionAccepted = nextEntity.Deserialise(this, entityReader, version);

                    // Was the schema version number accepted?
                    if (!versionAccepted) throw new SchemaVersionException(nextEntity.Type, nextEntity.SchemaVersion);

                    }
                result[index] = nextEntity;

                }

            // Return the entities
            return result;

            }

        internal void Write(params BaseEntity[] entities)
            {

            // Don't do anything if not writing any entities
            if (entities.Length == 0) return;

            // Update internal references to the entities
            foreach (var entity in entities)
                {
                this.Entities[entity.ID] = entity;
                }

            // Don't write to disk if there is no backing file
            if (!string.IsNullOrEmpty(FileName))
                {

                // Write the entities to disk
                using (var writer = new BinaryWriter(new FileStream(FileName, FileMode.Append, FileAccess.Write)))
                    {
                    writer.Write(entities.Length);
                    foreach (var entity in entities)
                        {

                        // Write the entity data to the database file itself
                        using (var memory = new MemoryStream())
                            {
                            using (var entityWriter = new BinaryWriter(memory))
                                {
                                entity.Serialise(entityWriter);
                                writer.Write(entity.ID.ToByteArray());
                                writer.Write((int)entity.Type);
                                writer.Write(entity.SchemaVersion);
                                writer.Write((int)memory.Length);
                                writer.Write(memory.ToArray());
                                }
                            }

                        }
                    }

                }

            }

        internal void WriteActivity(params LogEntry[] entries)
            {

            // Don't do anything is not writing any entries
            if (entries.Length == 0) return;

            // Write all entries to memory first
            foreach (var entry in entries)
                {
                Activity.Add(entry);
                }

            // Don't write to disk if there is no backing file
            if (!string.IsNullOrEmpty(FileName))
                {

                // Write to the end of the tape file
                using (var writer = new BinaryWriter(new FileStream(FileName, FileMode.Append, FileAccess.Write)))
                    {

                    // Write all given entries to the file
                    foreach (var entry in entries)
                        {
                        writer.Write((int)TapeAction.ActivityAudit);
                        writer.Write(entry.ToByteArray());
                        }

                    }

                }

            }

        internal void WriteActivity(ActivityItemType type, Guid id, ActivityAction action, DateTime time)
            {
            var entry = new LogEntry(type, id, action, time);
            WriteActivity(entry);
            }

        internal void Delete(BaseEntity entity)
            {

            // Remove the entity internally
            this.Entities.Remove(entity.ID);

            // Delete from disk only if there is a file name
            if (!string.IsNullOrEmpty(FileName))
                {

                // Write the deletion event to the database
                using (var writer = new BinaryWriter(new FileStream(FileName, FileMode.Append, FileAccess.Write)))
                    {
                    writer.Write(-1);
                    writer.Write(entity.ID.ToByteArray());
                    }

                }

            }

        private BaseEntity CreateEntity(FileEntryType type)
            {

            // Create a variable to hold the result
            BaseEntity result = null;

            // Create an empty entity of the given type
            switch (type)
                {

                case FileEntryType.Song:
                    result = new Song();
                    break;

                case FileEntryType.Category:
                    result = new Category();
                    break;

                case FileEntryType.License:
                    result = new License();
                    break;

                case FileEntryType.Style:
                    result = new Style();
                    break;

                case FileEntryType.Configuration:
                    result = new Configuration();
                    break;

                default:
                    throw new Exception("Invalid entity type '" + type + "'.");

                }

            // Return the entity
            return result;

            }

        }

    }
